# Part of dtcloud. See LICENSE file for full copyright and licensing details.
#----------------------------------------------------------
# ir_http modular http routing
#----------------------------------------------------------
import base64
import hashlib
import json
import logging
import mimetypes
import os
import re
import sys
import traceback
import threading

import werkzeug
import werkzeug.exceptions
import werkzeug.routing
import werkzeug.utils

from werkzeug.exceptions import Unauthorized
from jwt import ExpiredSignatureError, InvalidSignatureError, ImmatureSignatureError, DecodeError

try:
    from werkzeug.routing import NumberConverter
except ImportError:
    from werkzeug.routing.converters import NumberConverter  # moved in werkzeug 2.2.2

import dtcloud
from dtcloud import api, http, models, tools, SUPERUSER_ID
from dtcloud.exceptions import AccessDenied, UnauthorizedInvalidToken, \
    UnauthorizedDecodeError, UnauthorizedExpiredToken, UnauthorizedImmatureSignatureError, UnauthorizedUserNotExist, \
    UnauthorizedMoreVerifiedError
from dtcloud.http import request, Response, ROUTING_KEYS, Stream
from dtcloud.modules.registry import Registry
from dtcloud.service import security
from dtcloud.tools import get_lang, submap
from dtcloud.tools.translate import code_translations
from dtcloud.tools.jwt_utils import decode

_logger = logging.getLogger(__name__)


class RequestUID(object):
    def __init__(self, **kw):
        self.__dict__.update(kw)


class ModelConverter(werkzeug.routing.BaseConverter):

    def __init__(self, url_map, model=False):
        super(ModelConverter, self).__init__(url_map)
        self.model = model
        self.regex = r'([0-9]+)'

    def to_python(self, value):
        _uid = RequestUID(value=value, converter=self)
        env = api.Environment(request.cr, _uid, request.context)
        return env[self.model].browse(int(value))

    def to_url(self, value):
        return value.id


class ModelsConverter(werkzeug.routing.BaseConverter):

    def __init__(self, url_map, model=False):
        super(ModelsConverter, self).__init__(url_map)
        self.model = model
        # TODO add support for slug in the form [A-Za-z0-9-] bla-bla-89 -> id 89
        self.regex = r'([0-9,]+)'

    def to_python(self, value):
        _uid = RequestUID(value=value, converter=self)
        env = api.Environment(request.cr, _uid, request.context)
        return env[self.model].browse(int(v) for v in value.split(','))

    def to_url(self, value):
        return ",".join(value.ids)


class SignedIntConverter(NumberConverter):
    regex = r'-?\d+'
    num_convert = int


class LazyCompiledBuilder:
    def __init__(self, rule, _compile_builder, append_unknown):
        self.rule = rule
        self._callable = None
        self._compile_builder = _compile_builder
        self._append_unknown = append_unknown

    def __get__(self, *args):
        # Rule.compile will actually call
        #
        #   self._build = self._compile_builder(False).__get__(self, None)
        #   self._build_unknown = self._compile_builder(True).__get__(self, None)
        #
        # meaning the _build and _build unkown will contain _compile_builder().__get__(self, None).
        # This is why this override of __get__ is needed.
        return self

    def __call__(self, *args, **kwargs):
        if self._callable is None:
            self._callable = self._compile_builder(self._append_unknown).__get__(self.rule, None)
            del self.rule
            del self._compile_builder
            del self._append_unknown
        return self._callable(*args, **kwargs)


class FasterRule(werkzeug.routing.Rule):
    """
    _compile_builder is a major part of the routing map generation and rules
    are actually not build so often.
    This classe makes calls to _compile_builder lazy
    """
    def _compile_builder(self, append_unknown=True):
        return LazyCompiledBuilder(self, super()._compile_builder, append_unknown)


class IrHttp(models.AbstractModel):
    _name = 'ir.http'
    _description = "HTTP Routing"

    #------------------------------------------------------
    # Routing map
    #------------------------------------------------------

    @classmethod
    def _get_converters(cls):
        return {'model': ModelConverter, 'models': ModelsConverter, 'int': SignedIntConverter}

    @classmethod
    def _match(cls, path_info):
        rule, args = request.env['ir.http'].routing_map().bind_to_environ(request.httprequest.environ).match(path_info=path_info, return_rule=True)
        return rule, args

    @classmethod
    def _get_public_users(cls):
        return [request.env['ir.model.data']._xmlid_to_res_model_res_id('base.public_user')[1]]

    @classmethod
    def _auth_method_user(cls):
        if request.env.uid in [None] + cls._get_public_users():
            raise http.SessionExpiredException("Session expired")

    @classmethod
    def _auth_method_none(cls):
        request.env = api.Environment(request.env.cr, None, request.env.context)

    @classmethod
    def _auth_method_public(cls):
        if request.env.uid is None:
            public_user = request.env.ref('base.public_user')
            request.update_env(user=public_user.id)

    @classmethod
    def _auth_method_token(cls):
        cls.check_jwt_header()


    @classmethod
    def _authenticate(cls, endpoint):
        auth = 'none' if http.is_cors_preflight(request, endpoint) else endpoint.routing['auth']

        try:
            if request.session.uid is not None:
                if not security.check_session(request.session, request.env):
                    request.session.logout(keep_db=True)
                    request.env = api.Environment(request.env.cr, None, request.session.context)
            getattr(cls, f'_auth_method_{auth}')()
        except (AccessDenied, http.SessionExpiredException, werkzeug.exceptions.HTTPException):
            raise
        except Exception:
            _logger.info("Exception during request Authentication.", exc_info=True)
            raise AccessDenied()

    @classmethod
    def _geoip_resolve(cls):
        return request._geoip_resolve()

    @classmethod
    def _pre_dispatch(cls, rule, args):
        ICP = request.env['ir.config_parameter'].with_user(SUPERUSER_ID)

        # Change the default database-wide 128MiB upload limit on the
        # ICP value. Do it before calling http's generic pre_dispatch
        # so that the per-route limit @route(..., max_content_length=x)
        # takes over.
        try:
            key = 'web.max_file_upload_size'
            if (value := ICP.get_param(key, None)) is not None:
                request.httprequest.max_content_length = int(value)
        except ValueError:  # better not crash on ALL requests
            _logger.error("invalid %s: %r, using %s instead",
                key, value, request.httprequest.max_content_length,
            )

        request.dispatcher.pre_dispatch(rule, args)

        # Replace uid placeholder by the current request.env.uid
        for key, val in list(args.items()):
            if isinstance(val, models.BaseModel) and isinstance(val._uid, RequestUID):
                args[key] = val.with_user(request.env.uid)

        # verify the default language set in the context is valid,
        # otherwise fallback on the company lang, english or the first
        # lang installed
        env = request.env if request.env.uid else request.env['base'].with_user(SUPERUSER_ID).env
        request.update_context(lang=get_lang(env)._get_cached('code'))

        for key, val in list(args.items()):
            if not isinstance(val, models.BaseModel):
                continue

            try:
                # explicitly crash now, instead of crashing later
                args[key].check_access_rights('read')
                args[key].check_access_rule('read')
            except (dtcloud.exceptions.AccessError, dtcloud.exceptions.MissingError) as e:
                # custom behavior in case a record is not accessible / has been removed
                if handle_error := rule.endpoint.routing.get('handle_params_access_error'):
                    if response := handle_error(e):
                        werkzeug.exceptions.abort(response)
                if isinstance(e, dtcloud.exceptions.MissingError):
                    raise werkzeug.exceptions.NotFound() from e
                raise

    @classmethod
    def _dispatch(cls, endpoint):
        result = endpoint(**request.params)
        if isinstance(result, Response) and result.is_qweb:
            result.flatten()
        return result

    @classmethod
    def _post_dispatch(cls, response):
        request.dispatcher.post_dispatch(response)

    @classmethod
    def _post_logout(cls):
        pass

    @classmethod
    def _handle_error(cls, exception):
        return request.dispatcher.handle_error(exception)

    @classmethod
    def _serve_fallback(cls):
        model = request.env['ir.attachment']
        attach = model.sudo()._get_serve_attachment(request.httprequest.path)
        if attach and (attach.store_fname or attach.db_datas):
            return Stream.from_attachment(attach).get_response()

    @classmethod
    def _redirect(cls, location, code=303):
        return werkzeug.utils.redirect(location, code=code, Response=Response)

    def _generate_routing_rules(self, modules, converters):
        return http._generate_routing_rules(modules, False, converters)

    @tools.ormcache('key', cache='routing')
    def routing_map(self, key=None):
        _logger.info("Generating routing map for key %s", str(key))
        registry = Registry(threading.current_thread().dbname)
        installed = registry._init_modules.union(dtcloud.conf.server_wide_modules)
        if tools.config['test_enable'] and dtcloud.modules.module.current_test:
            installed.add(dtcloud.modules.module.current_test)
        mods = sorted(installed)
        # Note : when routing map is generated, we put it on the class `cls`
        # to make it available for all instance. Since `env` create an new instance
        # of the model, each instance will regenared its own routing map and thus
        # regenerate its EndPoint. The routing map should be static.
        routing_map = werkzeug.routing.Map(strict_slashes=False, converters=self._get_converters())
        for url, endpoint in self._generate_routing_rules(mods, converters=self._get_converters()):
            routing = submap(endpoint.routing, ROUTING_KEYS)
            if routing['methods'] is not None and 'OPTIONS' not in routing['methods']:
                routing['methods'] = routing['methods'] + ['OPTIONS']
            rule = FasterRule(url, endpoint=endpoint, **routing)
            rule.merge_slashes = False
            routing_map.add(rule)
        return routing_map

    @api.autovacuum
    def _gc_sessions(self):
        ICP = self.env["ir.config_parameter"]
        max_lifetime = int(ICP.get_param('sessions.max_inactivity_seconds', http.SESSION_LIFETIME))
        http.root.session_store.vacuum(max_lifetime=max_lifetime)

    @api.model
    def get_translations_for_webclient(self, modules, lang):
        if not modules:
            modules = self.pool._init_modules
        if not lang:
            lang = self._context.get("lang")
        langs = self.env['res.lang']._lang_get(lang)
        lang_params = None
        if langs:
            lang_params = {
                "name": langs.name,
                "direction": langs.direction,
                "date_format": langs.date_format,
                "time_format": langs.time_format,
                "grouping": langs.grouping,
                "decimal_point": langs.decimal_point,
                "thousands_sep": langs.thousands_sep,
                "week_start": langs.week_start,
            }
            lang_params['week_start'] = int(lang_params['week_start'])
            lang_params['code'] = lang

        # Regional languages (ll_CC) must inherit/override their parent lang (ll), but this is
        # done server-side when the language is loaded, so we only need to load the user's lang.
        translations_per_module = {}
        for module in modules:
            translations_per_module[module] = code_translations.get_web_translations(module, lang)

        return translations_per_module, lang_params

    @api.model
    @tools.ormcache('frozenset(modules)', 'lang')
    def get_web_translations_hash(self, modules, lang):
        translations, lang_params = self.get_translations_for_webclient(modules, lang)
        translation_cache = {
            'lang_parameters': lang_params,
            'modules': translations,
            'lang': lang,
            'multi_lang': len(self.env['res.lang'].sudo().get_installed()) > 1,
        }
        return hashlib.sha1(json.dumps(translation_cache, sort_keys=True).encode()).hexdigest()

    @classmethod
    def _is_allowed_cookie(cls, cookie_type):
        return True

    @classmethod
    def check_jwt_header(cls):
        from commons.cache.redis_utils import RedisCache
        from system_config import SysCfg
        def __del_hash_token(hash_key, list_token, hash_token):
            for key, _ in hash_token.items():
                if key not in list_token:
                    token_value = key
                    RedisCache.hdel(hash_key, token_value)

        def __del_token_hash_list(hash_key, list_key, token=None):
            ''' 处理多点认证的逻辑 '''
            list_token = RedisCache.lrange(list_key, 0, -1)
            hash_token = RedisCache.hgetall(hash_key)
            number = dtcloud.tools.config['user_auth_number'] or 2
            if token is None:
                if len(list_token) > number:
                    # list token 长度大于 2
                    del_list_token = list_token[number:]
                    for index in range(len(del_list_token)):
                        RedisCache.rpop(list_key)

                    __del_hash_token(hash_key, list_token[:number], hash_token)
                else:
                    # list token 长度小于 2, hash token 长度大于2
                    if len(hash_token) > number:
                        __del_hash_token(hash_key, list_token, hash_token)
            else:
                # hash表 删除旧的token
                RedisCache.hdel(hash_key, token)
                # redis list 抛出一个token
                RedisCache.ldel(list_key, 1, token)

        USER_TOKEN_KEY = 'AUTH:user_id:ID'
        USER_TOKEN = 'AUTH:user_id:id'

        token_header = {}
        token_payload = {}
        request_token = cls.get_request_token()
        try:
            header, payload = decode(request_token)
            if dtcloud.tools.config['is_authentication'] and dtcloud.tools.config['user_auth_number']:
                if payload.get('uid', ''):
                    uid = payload['uid']
                    _hash_key = USER_TOKEN_KEY + str(uid)
                    _list_key = USER_TOKEN + str(uid)

                    result_getall_token = RedisCache.hgetall(_hash_key)
                    _logger.info(f'Redis缓存中的tokens ==>> {list(result_getall_token.keys())}')
                    for key, value in result_getall_token.items():
                        if key == request_token:
                            break
                        if value == request_token:
                            _logger.info(f'刷新前的token ==>> {key}')
                            token = key
                            __del_token_hash_list(_hash_key, _list_key, token)
                            break
                    else:
                        _logger.info(f'被挤掉的token ==>> {request_token}')
                        _logger.info(f'Redis缓存中的tokens ==>> {list(result_getall_token.keys())}')
                        raise UnauthorizedMoreVerifiedError('当前账号已经存在多个登录，若要登录请先登出！')

            if header:
                token_header.update(header)
                del token_header['typ']
                del token_header['alg']
            if payload:
                token_payload.update(payload)
                del token_payload['exp']
                del token_payload['iat']
                del token_payload['nbf']

            request.update_env(payload['uid'])
            # 判断uid是否在res.users中存在
            if request.env['res.users'].browse(payload['uid']).exists():
                return token_header, token_payload
            else:
                raise UnauthorizedUserNotExist('Token uid is not exist.')
        except InvalidSignatureError:
            raise UnauthorizedInvalidToken('Invalid Token.')
        except DecodeError:
            raise UnauthorizedDecodeError('Token decode error.')
        except ExpiredSignatureError:
            raise UnauthorizedExpiredToken('Token expire date.')
        except ImmatureSignatureError:
            raise UnauthorizedImmatureSignatureError('Immature signature.')

    @classmethod
    def get_request_token(cls):
        headers = request.httprequest.headers
        if headers:
            authorization: str = headers.get('Authorization')
            if not authorization:
                raise Unauthorized(
                    'Authorization header is missing or empty.')
            if not authorization.lower().startswith('bearer'):
                raise UnauthorizedInvalidToken(
                    'Authorization header must start with Bearer.')
            return authorization[authorization.rfind(' '):].strip()
        return None